import {
  EventSourceMessage,
  fetchEventSource,
} from "@microsoft/fetch-event-source";
import fetchRetry from "fetch-retry";

import { NetworkError, ServerError } from "./errors";

let fetchOrigin: string;
let fetchFunctionSingleton: FetchFunction;
let fetchFunctionExtendedSingleton: FetchFunctionExtended;
let fetchHeaders: HeadersInit;
let fetchPathPrefix = "";

/**
 * Supported methods for accessing response data.
 */
export type FetchResultType =
  | "json"
  | "blob"
  | "text"
  | "arrayBuffer"
  | "json-stream";

/**
 * Configuration for a `fetch` call.
 */
export type FetchFunctionConfig<T> = {
  method: string;
  path: string;
  body?: T;
  result?: FetchResultType;
  retries?: number;
  retryCodes?: number[];
  errorHandler?: (response: Response) => void | Promise<void>;
  headers?: Record<string, string>;
};

/**
 * Parsed fetch response with response headers.
 */
export type FetchFunctionResult<T> = {
  response: T;
  headers?: Headers;
};

export interface FetchFunction {
  <A, R>(
    method: string,
    path: string,
    body?: A,
    result?: FetchResultType,
    retries?: number,
    retryCodes?: number[],
    errorHandler?: (response: Response) => void | Promise<void>,
    headers?: Record<string, string>
  ): Promise<R>;
}

/**
 * Extension of {@link FetchFunction} which provides response headers.
 */
export interface FetchFunctionExtended {
  <A, R>(
    method: string,
    path: string,
    body?: A,
    result?: FetchResultType,
    retries?: number,
    retryCodes?: number[],
    errorHandler?: (response: Response) => void | Promise<void>,
    headers?: Record<string, string>
  ): Promise<FetchFunctionResult<R>>;
}

export const getFetchFunction = () => {
  return fetchFunctionSingleton;
};

/**
 * Wrapper for {@link getFetchFunction} which provides response headers and
 * configuration via {@link FetchFunctionConfig}.
 */
export const getFetchFunctionExtended =
  (): (<A, R>(
    config: FetchFunctionConfig<A>
  ) => Promise<FetchFunctionResult<R>>) =>
  <A>(config: FetchFunctionConfig<A>) =>
    fetchFunctionExtendedSingleton(
      config.method,
      config.path,
      config.body,
      config.result,
      config.retries,
      config.retryCodes,
      config.errorHandler,
      config.headers
    );

export const getFetchHeaders = () => {
  return fetchHeaders;
};

const headersAsRecord = (headers: HeadersInit): Record<string, string> => {
  if (!headers) {
    return {};
  }

  let result = {};

  if (headers instanceof Headers) {
    headers.forEach((value, name) => (result[name] = value));
  } else if (Array.isArray(headers)) {
    // [["header-1", "value-1"], ["header-2", "value-2"]]
    headers.forEach(([name, value]) => (result[name] = value));
  } else {
    // {"header-1": "value-1", "header-2": "value-2"}
    result = {
      ...headers,
    };
  }

  return result;
};

export const mergeHeaders = (
  ...headers: HeadersInit[]
): Record<string, string> => {
  return (
    headers
      // convert everything to Record<string, string>
      .map((h) => headersAsRecord(h))
      // reduce with spread, ignoring any undefined values
      .reduce((a, b) => (a && b ? { ...a, ...b } : a ?? b), {})
  );
};

export const getFetchOrigin = () => {
  // window is not defined in the web worker
  if (hasWindow && window.FIFTYONE_SERVER_ADDRESS) {
    return window.FIFTYONE_SERVER_ADDRESS;
  }

  return fetchOrigin;
};

export function getFetchPathPrefix(): string {
  // window is not defined in the web worker
  if (hasWindow && typeof window.FIFTYONE_SERVER_PATH_PREFIX === "string") {
    return window.FIFTYONE_SERVER_PATH_PREFIX;
  }

  if (hasWindow) {
    const proxy = new URL(window.location.toString()).searchParams.get("proxy");
    return proxy ?? "";
  }

  return "";
}

export const setFetchParameters = (
  origin: string,
  headers: HeadersInit = {},
  pathPrefix = ""
) => {
  fetchHeaders = headers;
  fetchOrigin = origin;
  fetchPathPrefix = pathPrefix;
};

export const getFetchParameters = () => {
  return {
    origin: fetchOrigin,
    headers: fetchHeaders,
    pathPrefix: fetchPathPrefix,
  };
};

export const setFetchFunction = (
  origin: string,
  defaultHeaders: HeadersInit = {},
  pathPrefix = ""
) => {
  setFetchParameters(origin, defaultHeaders, pathPrefix);

  const fetchFunctionExtended: FetchFunctionExtended = async (
    method,
    path,
    body = null,
    result = "json",
    retries = 2,
    retryCodes = [502, 503, 504],
    errorHandler,
    headers
  ) => {
    let url: string;
    const controller = new AbortController();

    try {
      // if a valid URL is provided, make no adjustments
      new URL(path);
      url = path;
    } catch {
      if (fetchPathPrefix) {
        path = `${fetchPathPrefix}${path}`.replaceAll("//", "/");
      }

      url = `${origin}${
        !origin.endsWith("/") && !path.startsWith("/") ? "/" : ""
      }${path}`;
    }

    headers = mergeHeaders(
      { "Content-Type": "application/json" },
      defaultHeaders,
      headers
    );

    const fetchCall = retries
      ? fetchRetry(fetch, {
          retries,
          retryDelay: 500,
          retryOn: (attempt, error, response) => {
            if (
              (error !== null || retryCodes.includes(response.status)) &&
              attempt < retries
            ) {
              return true;
            }
          },
        })
      : fetch;

    const response = await fetchCall(url, {
      method: method,
      cache: "no-cache",
      headers,
      mode: "cors",
      body: body ? JSON.stringify(body) : null,
      signal: controller.signal,
      referrerPolicy: "same-origin",
    });

    if (!response.ok) {
      if (errorHandler) {
        await errorHandler(response);
      }
      // if custom error handler doesn't throw, use default error handling

      if (response.status >= 400) {
        const errorMetadata = {
          code: response.status,
          statusText: response.statusText,
          bodyResponse: "",
          route: response.url,
          payload: body as object,
          stack: null,
          requestHeaders: headers,
          responseHeaders: response.headers,
          message: "",
        };
        let ErrorClass = NetworkError;

        try {
          const error = await response.json();
          errorMetadata.bodyResponse = error;
          if (error.stack) errorMetadata.stack = error?.stack;
          errorMetadata.message = error?.message;
          if (error?.stack) {
            console.error(error.stack);
          }
          ErrorClass = ServerError;
        } catch {
          // if response body is not JSON
          try {
            errorMetadata.bodyResponse = await response.text();
          } catch {
            // skip response body if it cannot be read as text
          }
          errorMetadata.message = response.statusText;
        }

        throw new ErrorClass(errorMetadata, errorMetadata.message);
      }
    }

    if (result === "json-stream") {
      return {
        response: new JSONStreamParser(response, controller),
        headers: response.headers,
      };
    }

    return {
      response: await response[result](),
      headers: response.headers,
    };
  };

  fetchFunctionExtendedSingleton = fetchFunctionExtended;

  // convenience method which omits response headers
  fetchFunctionSingleton = async <A, R>(
    method: string,
    path: string,
    body: A = null,
    result: FetchResultType = "json",
    retries: number = 2,
    retryCodes: number[] = [502, 503, 504],
    errorHandler: (response: Response) => void | Promise<void>,
    headers: Record<string, string>
  ) =>
    fetchFunctionExtended<A, R>(
      method,
      path,
      body,
      result,
      retries,
      retryCodes,
      errorHandler,
      headers
    ).then((res) => res.response);
};

class JSONStreamParser {
  constructor(response, private controller: AbortController) {
    this.reader = response.body.getReader();
    this.decoder = new TextDecoder();
    this.partialLine = "";
  }

  private reader;
  private decoder: TextDecoder;
  private partialLine: string;

  abort() {
    return this.controller.abort();
  }

  async parse(callback) {
    while (true) {
      const { done, value } = await this.reader.read();
      if (done) {
        // End of stream
        break;
      }

      const chunk = this.decoder.decode(value);
      for (const c of chunk) {
        if (c === "\n") {
          const json = JSON.parse(this.partialLine);
          callback(json);
          this.partialLine = "";
        } else {
          this.partialLine += c;
        }
      }
    }
    if (this.partialLine) {
      // Process the last partial line, if any
      const json = JSON.parse(this.partialLine);
      callback(json);
    }
  }
}

const isWorker =
  // @ts-ignore
  typeof WorkerGlobalScope !== "undefined" && self instanceof WorkerGlobalScope;
const hasWindow = typeof window !== "undefined" && !isWorker;

export const getAPI = () => {
  if (import.meta.env?.VITE_API) {
    return import.meta.env.VITE_API;
  }

  if (window.FIFTYONE_SERVER_ADDRESS) {
    return window.FIFTYONE_SERVER_ADDRESS;
  }

  return window.location.origin;
};

if (hasWindow) {
  setFetchFunction(getAPI(), {}, getFetchPathPrefix());
}

class RetriableError extends Error {}
class FatalError extends Error {}

const polling =
  hasWindow &&
  typeof new URLSearchParams(window.location.search).get("polling") ===
    "string";
const MAX_REOPEN_ATTEMPTS = 10;
let eventSourceFetchErrorCount = 0;

export const getEventSource = (
  path: string,
  events: {
    onmessage?: (event: EventSourceMessage) => void;
    onopen?: () => void;
    onclose?: () => void;
    onerror?: (error: Error) => void;
  },
  signal: AbortSignal,
  body = {}
): void => {
  if (polling) {
    pollingEventSource(path, events, signal, body);
  } else {
    if (fetchPathPrefix) {
      path = `${fetchPathPrefix}${path}`.replaceAll("//", "/");
    }

    fetchEventSource(`${getFetchOrigin()}${path}`, {
      headers: { "Content-Type": "text/event-stream" },
      method: "POST",
      signal,
      body: JSON.stringify(body),
      referrerPolicy: "same-origin",
      async onopen(response) {
        if (response.ok) {
          events.onopen && events.onopen();
          eventSourceFetchErrorCount = 0;
          return;
        }

        if (response.status !== 429) {
          throw new FatalError();
        }

        throw new RetriableError();
      },
      onmessage(msg) {
        if (msg.event === "FatalError") {
          throw new FatalError(msg.data);
        }
        events.onmessage && events.onmessage(msg);
      },
      onclose() {
        events.onclose && events.onclose();
        throw new RetriableError();
      },
      onerror(err) {
        if (
          err instanceof TypeError &&
          ["Failed to fetch", "network error"].includes(err.message)
        ) {
          events.onclose && events.onclose();
          if (eventSourceFetchErrorCount <= MAX_REOPEN_ATTEMPTS) {
            eventSourceFetchErrorCount++;
            return;
          }
          throw err; // close event source
        }

        events.onerror && events.onerror(err);
      },
      fetch: async (input, init) => {
        try {
          const response = await fetch(input, init);
          if (response.status >= 400) {
            let err;
            try {
              err = await response.json();
            } catch {
              throw new Error(`${response.status} ${response.url}`);
            }

            throw new ServerError(
              {},
              (err as unknown as { stack: string }).stack
            );
          }

          return response;
        } catch (err) {
          throw err;
        }
      },
      openWhenHidden: true,
    });
  }
};

export const sendEvent = async (data: {}) => {
  return await getFetchFunction()("POST", "event", data);
};

interface PollingEventResponse {
  event: string;
  data: {
    [key: string]: any;
  };
}

const pollingEventSource = (
  path: string,
  events: {
    onmessage?: (event: EventSourceMessage) => void;
    onopen?: () => void;
    onclose?: () => void;
    onerror?: (error: Error) => void;
  },
  signal: AbortSignal,
  body = {},
  opened = false
): void => {
  if (signal.aborted) {
    return;
  }

  getFetchFunction()("POST", path, { polling: true, ...body })
    .then(({ events: data }: { events: PollingEventResponse[] }) => {
      if (signal.aborted) {
        return;
      }

      if (!opened) {
        events.onopen && events.onopen();
        opened = true;
      }

      data.forEach((e) => {
        events.onmessage &&
          events.onmessage({
            id: null,
            event: e.event,
            data: JSON.stringify(e.data),
          });
      });

      setTimeout(
        () => pollingEventSource(path, events, signal, body, opened),
        2000
      );
    })
    .catch((error) => {
      if (
        error instanceof TypeError &&
        ["Failed to fetch", "network error"].includes(error.message)
      ) {
        events.onclose && events.onclose();
        opened = false;
      } else {
        // todo: use onerror when appropriate? (colab network responses are unreliable)
        events.onclose && events.onclose();
      }

      setTimeout(
        () => pollingEventSource(path, events, signal, body, opened),
        2000
      );
    });
};

const getFirstFilterPath = (requestBody: any) => {
  if (requestBody && requestBody.query) {
    if (requestBody.query.includes("paginateSamplesQuery")) {
      const pathsArray = requestBody?.variables?.filters;
      const paths = pathsArray ? Object.keys(pathsArray) : [];
      return paths.length > 0 ? paths[0] : undefined;
    }
    if (requestBody.query.includes("lightningQuery")) {
      const paths = requestBody?.variables?.input?.paths;
      return paths && paths.length > 0 ? paths[0].path : undefined;
    }
  }
  return undefined;
};
